Skip to content

[DO NOT MERGE] Infer through Class#new for unparameterized and generic Class receivers - #59

Draft
apiology wants to merge 2 commits into
masterfrom
poc-classnew-inference
Draft

[DO NOT MERGE] Infer through Class#new for unparameterized and generic Class receivers#59
apiology wants to merge 2 commits into
masterfrom
poc-classnew-inference

Conversation

@apiology

Copy link
Copy Markdown
Owner

Parked: opened for future reference; the underlying defect is definition-site-only (callers are unaffected), so this waits until the problem is worth solving.

Problem

At typecheck --level strong, Solargraph cannot verify the declared return type of a method that calls .new through a Class-typed receiver: it reports "return type could not be inferred" at the definition and forces a suppression there. The failure is confined to the definition site. Callers are unaffected — the declared return resolves at call sites, so there is no viral undefined from this defect.

Both shapes below are ordinary Ruby. The first is the instantiate-the-class-under-test helper that every typed test suite grows:

# @generic T
# @param clazz [Class<generic<T>>]
# @return [generic<T>]
def create_object(clazz)
  clazz.new       # => "return type could not be inferred", despite the generic
end

obj = create_object(MyCheck)  # obj types as MyCheck: legitimate calls check
                              # cleanly, bogus calls error - the declaration
                              # firewalls callers

The second is the throwaway-class idiom behind test doubles and one-off adapters — build an anonymous class implementing a duck interface, instantiate it once:

# A responder object defining exactly the given no-op methods (test double)
# @param interface_methods [Array<Symbol>]
# @return [Object]
def duck_responder(interface_methods)
  Class.new do
    interface_methods.each { |m| define_method(m) { nil } }
  end.new   # => false error at the definition; @return [Object] already covers callers
end

(The define_method resolution inside the block is castwide#1310's territory; this PR fixes the return inference.)

The value of this change is declaration verifiability — the definition typechecks against its own declared return, and the definition-site suppressions can be removed. It does not add caller-side coverage, which was never broken.

Solution

ApiMap#get_methods already repairs the untyped Class#new pin per-namespace (synthesizing new from initialize), but misses two receiver shapes:

  1. Unparameterized Class — the repair deliberately skips rooted_tag Class/Class<Class>. Now proxied: Class.newClass<Object>, .new on a bare-Class-typed receiver → Object.
  2. Class<generic<T>> — class-method lookup on the nonexistent generic namespace finds no Class#new at all. Now synthesized: a permissive (restarg/kwrestarg) new returning the generic tag, which caller-side binding already resolves.

The synthesized Class<generic<T>>#new accepts any arguments: the initializer is statically unknowable from that type, so argument checking is out of scope here. That trade is strictly non-regressive — today the call is unresolved at the definition, which means a false error and zero argument checking; the fix adds typed returns without adding new unsoundness. (Sorbet's T::Class docs similarly note the type "only assumes those [methods] that are defined on ::Class… basically, just .new and .name," without documenting constructor-argument checking for such receivers.)

The new types are sound upper bounds: Class<Object> means "a class whose instances are (at least) Object," so .new → Object claims the bound, not the exact class — at runtime Class.new.new is an instance of a fresh anonymous class, never an Object-classed value. Object is chosen over the strictly-lower bound BasicObject because a BasicObject-typed result rejects nearly every subsequent call, and it matches Ruby's default superclass and existing core fills; the trade-off is one unsoundness edge for explicit Class.new(BasicObject) receivers, noted under alternatives.

Background / prior art

Root cause: RBS cannot parameterize Class, so it declares Class#new: (*untyped, **untyped) -> untyped. At runtime, Class.new builds a fresh anonymous class (unnamed until assigned to a constant, superclass Object by default), and its instances inspect as #<#<Class:0x...>:0x...> — the inner #<Class:0x...> is the anonymous class's address-based display name, the outer wrapper the instance of it.

A singleton class "holds methods for only that instance," and the classic factory case — Foo.new — dispatches in Foo's singleton class, which is where the attached-class mechanisms live: Sorbet's T.attached_class ("an instance of the current class") and RBS's instance type are both resolved against that statically-known singleton context. Our two shapes operate outside any such context: the receiver is a value typed Class, so .new is instance-method dispatch on class Class and no singleton class is statically in play. Sorbet bridges that gap with the applied type T::Class[X] — "any class object which, when instantiated, creates instances that at least have type X" — an explicit upper bound built on the same mechanism as T.attached_class; RBS has no parameterization of Class at all, hence untyped. Solargraph's Class<X> is its T::Class[X] analog, and this PR supplies the most precise types expressible in Solargraph's current syntax under that reading.

Related but distinct: castwide#1303 (constants assigned from Class.new blocks); castwide#1310 addresses block-self inside Class.new do ... end.

Alternative solutions

  • A first-class attached-class type in Solargraph's annotation syntax — an attached token (named to avoid colliding with RBS's classish-context instance semantics), legal where the context supplies a Class<X>:

    # In a Class#new annotation: resolves to X for a Class<X> receiver, Object for bare Class
    # @return [attached]
    # In a singleton-method context: the T.attached_class analog (instance of the current class or subclass)

    Class#new could then be declared once and the per-namespace new synthesis in get_methods — including both shapes fixed here — would collapse into a single declaration plus binder-aware substitution (the same shape as the existing selfself_to_type handling). Not taken in this PR because it is new public annotation syntax (YARD compatibility, documentation, cross-tool expectations) touching ComplexType parsing, dispatch substitution, and generics erasure; the bounded proxy needs none of that, and its two proxy sites are exactly where an attached token would later slot in.

  • Superclass-argument binding for Class.new(Superclass) — conceptually @generic T / @param superclass [Class<generic<T>>] / @return [Class<generic<T>>] on the synthesized pin, inferring Class<StandardError> from Class.new(StandardError) and closing the Class.new(BasicObject) unsoundness edge. Three reasons it stays out of this PR. First, it semantically conflicts with the defect-2 fix in this same PR: superclass binding needs an unbound generic to degrade to its bound (no-arg Class.new must fall back to Class<Object>), while the Class<generic<T>>#new fix needs unbound generics kept symbolic for later caller-side binding — reconciling the two requires a design decision about when degradation happens, which deserves its own review. Second, blast radius: probed while preparing this PR, the pin synthesizes correctly (generics, typed optarg, generic return) but the chain layer's call-site binding does not engage for API-synthesized pins — the unresolved Class<generic<T>> flows through and the no-arg case regresses to symbolic — so the fix lands in chain-layer resolution code that affects every method call, versus this PR's repair of one method's pin. Third, marginal value: Class.new(Superclass) results that need precise typing are typically assigned to constants, which is Methods on a Class.new-defined class are unresolvable and cannot be stubbed castwide/solargraph#1303's territory regardless of what the expression infers. The static Class<Object> proxy is forward-compatible with adding the binding later.

Test plan

Two clip specs updated from asserting undefined, six added (both shapes, caller-side generic binding, args-through-generic-new). Full suite: 1630 examples, 0 failures, 60 pending.

Opened as a draft. This PR was written by Claude (Anthropic's Claude Code) on behalf of @apiology.

apiology and others added 2 commits August 16, 2026 16:15
RBS declares Class#new as (*untyped) -> untyped because RBS cannot
parameterize Class. ApiMap#get_methods already replaces that pin with a
self-returning synthesis for concrete namespaces, but deliberately skips
rooted_tag Class / Class<Class>, leaving untyped - so Class.new.new and
k.new (k: bare Class) were uninferrable. Proxy the pin instead: scope
:class (literal Class.new) -> Class<Object> (the default-superclass
anonymous class), scope :instance (.new on an unparameterized
Class-typed receiver) -> Object (some instance of an unknown class).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
For a receiver typed Class<generic<T>>, singleton-method lookup runs
against the nonexistent 'generic' namespace, so no Class#new pin ever
appears and .new goes undefined - even though the instance type is
exactly the generic the caller binds. Synthesize a permissive new pin
(restarg/kwrestarg, since the real initializer is unknown) returning the
generic tag itself. Caller-side binding already worked; this fixes the
method-body side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FEjW6nMpZrWPmeWX9miT
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant